Repository navigation
fix(genrm_compare): keep a completed cohort through a checkpoint - #4792
Conversation
| # its reward but before the others did. Those others replay verification, and their sibling never will: | ||
| # rebuilding the cohort from them alone would wait forever. So each session still open on a completed cohort | ||
| # exports its member's reward, and a restore installs the completed cohort that answers those replays. | ||
| # While a checkpoint is open, rewards are held until resume, so no member records one after this export. |
There was a problem hiding this comment.
A cohort whose judge fails gets neither the hold nor the export, so the stall this PR fixes still happens when the judge fails.
judge_failsafe turns the cohort's JudgeError into an HTTP 200 masked row (reward: 0.0, mask_sample: true). The caller records that row as the episode's result, just as it records a reward. But _fail_verify_cohort delivers it without waiting for resume, and _completed_member_state exports {} for any cohort whose phase isn't completed.
For example, say a checkpoint is open and the cohort is still evaluating, so the GenRM export writes {} for every member.
- The judge deadline expires, and every member gets the masked row at once.
- Member A's caller records the row before its own export, but member B's reply is processed after that export, so B is exported at its verify boundary.
- After a crash, B replays into a process that has no cohort. It waits
cohort_collection_timeout_s, then gets a 503 instead of the masked row that A recorded.
The same happens when the failure lands before admission closes, because a failed cohort is also exported as empty.
Could the export include failed cohorts that have a group ID, along with failure and failure_kind? Then a replayed verification would raise the same JudgeError and return the same masked row. Judge failures could also wait for resume, the same way rewards now do. If that's out of scope here, this comment should say that only completed cohorts are covered, since the last sentence currently reads as if every recorded result is.
There was a problem hiding this comment.
Fixed in c8eaf759c. A cohort the judge failed now exports the failure, so after a restore a replayed verification raises the same JudgeError and judge_failsafe returns the same masked row. The judge-failure paths in the evaluation (a JudgeError and the evaluation deadline) are also held while a checkpoint is open, as rewards are. Cohort failures that callers don't record as a row (collection timeout, superseded attempt, shutdown) still export nothing and replay as before.
Tests: test_a_member_that_has_not_recorded_its_judge_failure_gets_it_back_after_a_restore, and test_outcomes_are_held_while_a_checkpoint_is_open[judge_failure]. Each fails if its half of the change is removed. Through the HTTP control plane, the judge-failure case on main stalls for the full collection timeout. Here it returns the masked row at once, after one or two checkpoints, without calling the judge.
| updated_at=now, | ||
| ) | ||
|
|
||
| async def retire_session_state(self, session_id: str) -> None: |
There was a problem hiding this comment.
When a restored session is retired or deleted, this hook forgets that session's member. But the restored cohort, and the watermark the restore raised, stay until cohort_result_ttl_s or the count cap evicts them. Until then they block the rollout from restarting from input.
Two controller paths retire restored sessions this way:
- A failed restore. Coordination retires the next attempts on every participant (
coordination.py), then starts the rollouts over from their inputs. - A commit that leaves a restored episode out of its scope. It calls
delete_restored, whose docstring says the controller "may still start the rollout over as that attempt".
In both cases the group attempt stays the same. The README says a rollout's retry counter doesn't advance _ng_group_attempt, and the design doc says the group re-forms with the same attempt. So each regenerated response reaches the restored completed cohort and gets 409 "already has a different response for rollout_index=N". That lasts for up to an hour with the code default, or six hours with the README's example config. On main, the fresh process holds no cohort, so the group re-forms and completes.
flowchart LR
R[restore installs completed cohort G attempt 0] --> F{another participant fails install, or commit scope drops the episode}
F --> X[retire_session_state forgets the member only]
X --> S[rollout restarts from input: new response, same group attempt]
S --> C[409: already has a different response]
A smaller case has the same fix. A member that was reported as a restart, or that fell outside the commit scope, is missing from the restored cohort. When it reruns, it gets 409 "already completed; rollout_index=N cannot be added". That needs a group that mixes restarts with continued members, so it's narrow.
Could the restored cohort track which restored sessions back each member? Then, when a member's last restored session is retired or deleted before it replays, drop that member. Once no restored member is left, drop the cohort and the watermark the restore installed, so the group can re-form as it does on main.
There was a problem hiding this comment.
Fixed in c8eaf759c, as suggested. A restore records which restored sessions back each member and haven't replayed yet. When such a session is retired or deleted before its member replays, through a failed restore's retire or delete_restored for a commit that leaves the episode out of scope, the member is dropped. A restored cohort left with no member is dropped together with the watermark its restore installed, unless live traffic has used that watermark since. The group then re-forms with the same attempt, as on main. A member that replayed stays, since it continued from the cohort.
Tests: test_a_restored_cohort_nothing_continues_from_is_dropped[failed_restore|left_out_of_commit] regenerate both members afterwards and complete the group. test_a_restored_cohort_stays_once_a_member_has_replayed covers the other side.
The narrower case you mention, a restart rerunning into a cohort that still holds continued members, still gets a 409 for its slot, and I've left it.
| def _remember_member(self, prompt_key: str, rollout_index: int) -> None: | ||
| """Record which cohort member the requesting attempt verified as; only checkpointing needs it.""" | ||
| episode_id = current_episode_id() | ||
| if episode_id is not None and self.checkpoint_participant is not None: | ||
| self._episode_members[episode_id.capture_key] = (prompt_key, rollout_index) |
There was a problem hiding this comment.
With checkpointing on, this records a member for every verification, including cohorts without _ng_group_id, and nothing ever frees those entries.
- A successful cohort without a group ID is popped in
_publish_verify_cohort_locked, not in_prune_terminal_cohorts. The filter there runs only when that prune call itself drops a cohort, so it never runs when all traffic is like this. - A session that ends normally, through
/verifyor/close_session, goes throughResourcesParticipant.ended(), which doesn't callretire_session_state.
So a run whose verifications carry no group ID adds one entry per rollout for the life of the process. A reproduction with 20 such cohorts and max_terminal_cohorts=2 was left holding 40 entries. The entries are never used either, because _completed_member_state returns {} for a cohort without a group ID. NeMo RL always sends a group ID, but every per-rollout structure still needs an end of life.
Recording only members of cohorts that have a group ID fixes it. The two call sites in verify would pass cohort instead of prompt_key:
| def _remember_member(self, prompt_key: str, rollout_index: int) -> None: | |
| """Record which cohort member the requesting attempt verified as; only checkpointing needs it.""" | |
| episode_id = current_episode_id() | |
| if episode_id is not None and self.checkpoint_participant is not None: | |
| self._episode_members[episode_id.capture_key] = (prompt_key, rollout_index) | |
| def _remember_member(self, cohort: _CohortState, rollout_index: int) -> None: | |
| """Record which member of a cohort with a group ID the requesting attempt verified as.""" | |
| episode_id = current_episode_id() | |
| if cohort.group_id is not None and episode_id is not None and self.checkpoint_participant is not None: | |
| self._episode_members[episode_id.capture_key] = (cohort.key, rollout_index) |
There was a problem hiding this comment.
Fixed in c8eaf759c along with the switch to session IDs. Only members of cohorts with a group ID are recorded, so a cohort without one adds nothing (test_a_cohort_without_a_group_id_records_no_members). Retiring a session pops its entry, and aclose clears the map.
| for group_id, _ in prunable_watermarks[: max(0, excess)]: | ||
| self._latest_group_attempts.pop(group_id, None) | ||
|
|
||
| if len(self._verify_cohorts) < cohorts_before: |
There was a problem hiding this comment.
This rebuilds both member maps on every prune call that drops any cohort. Once the server is at max_terminal_cohorts, that happens once per completed cohort. With the defaults (4096 cohorts, 16 members each), each rebuild walks about 65,000 entries while holding _cohort_registry_lock, which measured roughly 10 to 40 ms.
The existing sort in this prune is the same order of cost, so this isn't urgent. Still, keeping each cohort's member keys on _CohortState, and deleting them wherever the cohort is popped, would make cleanup proportional to what was evicted. Those places are the TTL prune, the count prune, the publish pop for cohorts without a group ID, and aclose. It would also give the publish pop an end-of-life path, and it maps directly onto #3625's eviction points, so the rebase gets easier.
There was a problem hiding this comment.
Done in f75230a36. Each cohort keeps its member sessions, and the restored ones not replayed yet, on _CohortState. _drop_cohort removes exactly those records wherever a cohort goes: the TTL prune, the count prune, a dropped restore and aclose. Pruning no longer rebuilds the map. A cohort without a group ID records no members, so the publish pop has nothing to clean. test_member_records_end_with_their_pruned_cohort fails if the cleanup is removed.
| await self.hooks.retire_session_state(session_id) | ||
| self.ended(session_id) | ||
|
|
||
| def session_episode(self, session_id: str) -> Optional[EpisodeId]: |
There was a problem hiding this comment.
GenRM could key its members by the resources session ID it already receives. That would remove this accessor and one of GenRM's two maps, and it needs no move to the typed session interface.
ResourcesCheckpointMiddlewarebinds sessions by the cookie'sSESSION_ID_KEY, and the participant passes those same IDs toexport_session_states,restore_session_statesandretire_session_state. Ifverifyreads that ID too, GenRM's keys match the participant's on every path.- The session middleware mints that ID whether an agent's
/runseeded with the untyped body or an environment server sent a typed seed, where it equalsresources_session_id. GenRM keeps no seed state, so the defaultseed_sessionandclose_resources_sessionalready cover both. judge_failsafeandtraced_verify_endpointboth keepverify's signature withfunctools.wraps, so FastAPI injects arequestparameter, as it does forworkplace_assistant.- A restore installs the same session IDs, and the restored boundary replays with the same cookie. So one map from session ID to member works both before and after a replay. That covers the restore-then-checkpoint case the second commit handles with
_session_members.
A sketch:
async def verify(self, request: Request, body: GenRMCompareVerifyRequest) -> GenRMCompareVerifyResponse:
session_id = request.session.get(SESSION_ID_KEY)
...
# on both remember paths, instead of current_episode_id():
if cohort.group_id is not None and session_id is not None and self.checkpoint_participant is not None:
self._session_members[session_id] = (cohort.key, rollout_index)Export then looks members up by session ID alone, restore fills the same map, and retire_session_state pops one key. _episode_members, the current_episode_id() lookup, and this new public method on the shared participant all go away. The tests that call server.verify(body) directly would need a request carrying a session. The map still needs the cohort cleanup from the comment on the prune filter.
Reordering install to bind sessions before it calls restore_session_states would also fix the second commit's case. But it changes the contract for every exported resources server and overlaps the install changes in #3909 and #4778, so it should be its own PR if anyone wants it.
There was a problem hiding this comment.
Done in c8eaf759c. verify now takes request: Request and reads SESSION_ID_KEY, as workplace_assistant does, and one session-keyed map serves both before and after a replay. _episode_members, the current_episode_id() lookup and ResourcesParticipant.session_episode() are gone, so the PR no longer changes nemo_gym/. The existing tests that call verify directly now pass a session-less request built from the scope the session middleware leaves (tests/support.py). The checkpoint tests bind sessions on the real ResourcesParticipant.
| result=cohort.results[rollout_index], | ||
| ).model_dump(mode="json") | ||
|
|
||
| async def export_session_states(self, session_ids: list[str]) -> dict[str, JsonValue]: |
There was a problem hiding this comment.
#4220, which #4778 is stacked on, changes this hook to export_session_states(self, session_ids, *, checkpoint_id: str), and ResourcesParticipant.export passes checkpoint_id= to it. The two PRs merge without a textual conflict. But once both land, this override raises TypeError on every commit, so no checkpoint can commit on a deployment that runs GenRM.
A trial merge of #4778 into this branch reproduced it, first in test_checkpoint.py's participant.export(None) call and then here. Whichever PR lands second needs to add the keyword argument to this override and update those test calls. A note in both PR descriptions would keep that from being missed.
| group_id: str | ||
| group_attempt: int = Field(ge=0) | ||
| rollout_index: int = Field(ge=0) | ||
| response_digest: str |
There was a problem hiding this comment.
The digest saved here is whatever _response_digest produces. #3625 changes that function from json.dumps(..., ensure_ascii=True) to orjson.dumps, which writes UTF-8.
For any response with non-ASCII text the two digests differ. "café", for example, hashes differently under each. A checkpoint written before #3625 and restored after it would then get 409 "already has a different response" on every exact replay of such a member, and the reverse direction fails the same way.
Could this state carry a digest scheme field? This model sets extra="forbid", so a binary that doesn't know the field would refuse the state rather than misread it. A binary that does know it could recompute or skip the digest comparison when the scheme differs. Landing #3625 first would also avoid it, because no GenRM checkpoint would ever hold digests in the old format.
There was a problem hiding this comment.
Done in ac04de348. Saved members now carry response_digest_scheme (RESPONSE_DIGEST_SCHEME = "json-ascii-sha256", defined next to _response_digest, with a comment that a serialization change must change the name). It's a fixed Literal under extra="forbid", so a restore refuses state saved under a scheme it doesn't know instead of installing digests it can't compare (test_a_restore_refuses_digests_saved_under_another_scheme). #3625 has been idle with conflicts since Sep 29, so I didn't rebase onto it. When it rebases, it can name its orjson scheme and skip the digest comparison for members restored under the old one. I've left a note there.
| participant.sessions = {"session-0": EPISODES[0], "session-1": EPISODES[1]} | ||
| participant.close() | ||
| members = [asyncio.create_task(verify_as(server, EPISODES[index], member(index))) for index in range(2)] | ||
| for _ in range(20): |
There was a problem hiding this comment.
This loop leaves little margin. With the hold removed from _publish_verify_cohort, the test still passes at 4 iterations and only starts failing at about 6. await_count > 0 shows the judge was called, not that publication reached the hold. So one extra await in the evaluation path, which #3625's rewrite adds, would let this test pass without checking the hold.
Waiting until the publish is actually inside wait_open makes it deterministic, for example:
async def until(condition, timeout: float = 2) -> None:
async with asyncio.timeout(timeout):
while not condition():
await asyncio.sleep(0)
def count_wait_open(participant) -> dict[str, int]:
counts = {"entered": 0}
wait_open = participant.wait_open
async def counting_wait_open() -> None:
counts["entered"] += 1
await wait_open()
participant.wait_open = counting_wait_open
return countsThen the test can replace the loop with await until(lambda: counts["entered"] == 1).
There was a problem hiding this comment.
Fixed in c8eaf759c, using your until / count_wait_open approach: the test waits until the outcome is actually inside wait_open, and is parametrized over rewards and judge failures. f75230a36 adds a test that reopens and recloses admission while the outcome waits for the cohort lock. It covers the re-check under the lock for both publication and failure; without either re-check, the matching case fails.
| ) | ||
|
|
||
|
|
||
| class FakeParticipant: |
There was a problem hiding this comment.
Most of these tests run against this copy of the participant's surface instead of ResourcesParticipant. Only the last test uses the real one, so the rest can't catch a change in how the real participant binds sessions or reports admission.
Mutation runs also show that the end-of-life paths of the new maps, and one atomicity check, are untested. Each of these changes leaves every test in this file passing:
- making
retire_session_statea no-op; - removing both filters in
_prune_terminal_cohorts; - removing
aclose's clearing of_episode_membersand_session_members; - removing the re-check of
acceptingundercohort.lock; - skipping
_remember_memberon the existing-member path; - removing the check that restored sessions agree on a member.
Building the tests on participant_server would close most of these gaps. Drive admission with close_admission/open_admission, bind sessions with seeded, and add tests asserting that the maps are empty after retire, prune and aclose. A test through the HTTP middleware is also missing: rollout prefix, close-follows header, then prepare, commit, restore, resume and replay. A version of that test passes on this branch and can be shared.
There was a problem hiding this comment.
Addressed in c8eaf759c and f75230a36. test_checkpoint.py now runs on the real ResourcesParticipant: sessions bind with seeded, admission goes through close_admission/open_admission, and restores go through install, retire and delete_restored. Each mutation from your list now fails a test:
retire_session_stateas a no-op;- the prune cleanup removed;
aclosenot clearing members;- the re-check of
acceptingundercohort.lock, for both rewards and judge failures; - skipping the member record on the existing-member (replay) path;
- the check that restored sessions agree on a member.
It also adds a test through the HTTP middleware: seed, verify with the close-follows header, prepare, commit, restore, resume and replay. It runs after one and after two checkpoint-crash-restore cycles, for rewards and for judge failures. The same flow on main stalls for the full collection timeout.
|
Thanks for closing this race. The reasoning in the description holds up. The hold is needed: without it, a cohort published after the GenRM export but before a caller's own export leaves one member recorded and a sibling at its verify boundary. It also can't deadlock a checkpoint. Resources close admission in the last prepare stage, and by then each caller's stage has already waited for its The inline comments cover two gaps next to the case this PR fixes, plus cleanup:
Which crash cuts this coversflowchart TD
A[cohort state when GenRM exports] --> B{phase}
B -->|collecting or evaluating| C[export empty; publication held until resume]
C --> C1[all members replay and rebuild the cohort, as before]
B -->|completed| D[each open session exports its member's reward]
D --> D1[restore installs the completed cohort; replays get saved rewards]
D1 --> D2{restored session retired or deleted before replay}
D2 -->|no| D3[covered by this PR]
D2 -->|yes| D4[cohort and watermark stay; restart from input gets 409]
B -->|failed by judge| E[export empty; masked rows delivered during the checkpoint]
E --> E1[a sibling at its verify boundary waits cohort_collection_timeout_s]
Overlap with other open PRs
Not on a changed line
Validation done for this review
|
|
Thanks for the thorough review. Round 1 is in
Still open: the |
yuhezhang-ai
left a comment
There was a problem hiding this comment.
Thanks for the updates. The legacy member-tracking leak and the lock-race test gap are addressed in the cases I rechecked. I found two additional points below: saved-outcome eviction needs a fix before merge; the distinct-member reward test request is nonblocking.
Validation at ac04de34: 353 focused GenRM/checkpoint tests passed, and Ruff lint/format checks passed. The eviction reproduction uses the real checkpoint participant with a mocked judge; no real-model or trainer run was performed.
| self._session_members.update(session_members) | ||
| now = time.monotonic() | ||
| for key, cohort in cohorts.items(): | ||
| cohort.terminal_at = now |
There was a problem hiding this comment.
[P2] Keep saved outcomes while open sessions still need them.
At ac04de34, with max_terminal_cohorts=1, finish two two-member groups, close member 0 in each, then export and restore the remaining sessions. Both outcomes are saved, but replaying the older group prunes its outcome and waits for the sibling that already finished. This is a reachable checkpoint: pruning runs before collection, so completing the second group can leave two terminal groups until the next verification.
With the collection timeout shortened to 0.05 seconds, replay returns HTTP 503 and the next export contains empty state for that session. This affects saved judge failures as well as rewards.
Please retain outcomes needed by open checkpoint sessions independently of terminal-cache eviction, with regression coverage for both count-based and time-based pruning. The count-based case above is reproduced; the time-based path calls the same unconditional cleanup.
There was a problem hiding this comment.
Thanks, reproduced. Fixed in 4ab8322.
A finished cohort is now kept while a member session the checkpoint participant still holds open belongs to it. Such a cohort skips both the cohort_result_ttl_s expiry and the max_terminal_cohorts count, and its group keeps its attempt watermark. "Open" is the participant's own view (is_open), not GenRM's member list: a member that records its outcome closes its session without a retire, so the member list alone would have kept nearly every cohort. Once those sessions close or are retired, the cohort is an ordinary tombstone again and is pruned as before. This also covers the live case: a cohort with an unrecorded member can no longer be evicted before a checkpoint exports it.
test_pruning_keeps_an_outcome_an_open_session_still_needs runs your scenario (two finished groups, member 0 of each closed, export, restore, replay both) by count (max_terminal_cohorts=1) and by age (cohort_result_ttl_s=0.01), for rewards and judge failures. Each replay returns its own outcome at once without calling the judge. While the cohorts are kept, both groups keep their watermarks. After the sessions close, pruning removes them again. test_member_records_end_with_their_pruned_cohort now closes both sessions before expecting the prune. Each of these mutations fails a test: keeping nothing, keeping any cohort with a member record (open or not), and dropping the kept cohort's watermark.
| restored, _ = await restore(config, records) | ||
| replayed = await asyncio.wait_for(verify_as(restored, SESSIONS[1], member(1)), timeout=0.5) | ||
|
|
||
| assert replayed.reward == outcomes[1].reward |
There was a problem hiding this comment.
[P3, nonblocking] Use distinct final rewards to check member identity.
The new lock-race and checkpoint-control tests address those earlier gaps. The member-identity assertion still needs distinct final rewards: this fixture produces 3.0 for both members, so the assertion cannot detect a sibling mix-up.
I changed export to save the other member's result, and all 22 checkpoint tests still passed. Please use different final rewards and verify that each member recovers its own outcome, including after a second checkpoint.
This is a regression-test coverage request; I have not found incorrect reward assignment in the unmodified implementation.
There was a problem hiding this comment.
Done in 4ab8322. The identity tests and the HTTP control-plane test now use a judge under which member 0 wins in either comparison order, so the two members finish with different rewards (each test asserts they differ). test_a_restored_cohort_survives_another_checkpoint_before_its_members_replay also runs with both members still open at the checkpoint, and checks that each gets back its own reward after two checkpoint-crash-restore cycles. With export changed to save the sibling's result, 8 tests now fail, including the second-checkpoint cases.
|
/ok to test 4ab8322 |
yuhezhang-ai
left a comment
There was a problem hiding this comment.
Rechecked 4ab83223: both of my findings are addressed. The original eviction reproduction now recovers the saved reward or judge failure and preserves it on re-export. The tests use distinct member rewards, and the same sibling-result substitution that previously passed now fails seven tests, including repeated-checkpoint and HTTP recovery cases.
Validation: 358 focused GenRM/checkpoint tests passed, Ruff lint/format checks passed, and all required GitHub checks pass. Recovery validation used a mocked judge and real checkpoint participant/control routes; no live trainer run was performed.
Approving this scoped checkpoint fix. The documented whole-group restart requirement and merge-order compatibility notes still apply.
ananthsub
left a comment
There was a problem hiding this comment.
Thanks for the thorough follow-up: every earlier comment checks out at 4ab832238 except the #4220 signature, which still depends on merge order.
Requesting changes for one blocker: on a merge with the latest main, #4997's rename of verify_mode to verify_step_type makes every test in test_checkpoint.py fail. With that keyword renamed, the GenRM and checkpoint suites pass on the merged tree, so a rebase plus the one-line fix is all it needs. The other inline comment is about the new keep-open check, which now scans every finished cohort's sessions on each grouped verification.
| def participant_server(config, *, judge=None) -> tuple[genrm.GenRMCompareResourcesServer, ResourcesParticipant]: | ||
| """A server behind the real resources participant, so sessions bind and restore as they do in Gym.""" | ||
| server = genrm.GenRMCompareResourcesServer.model_construct(config=config, server_client=MagicMock()) | ||
| participant = ResourcesParticipant(server, "exported", verify_mode="replay") |
There was a problem hiding this comment.
This keyword no longer exists on main. #4997 renamed verify_mode to verify_step_type on ResourcesParticipant, so on the merge with the latest main every test in this file fails while building the participant (TypeError: ResourcesParticipant.__init__() got an unexpected keyword argument 'verify_mode'). Git reports no conflict, so only a rebase or CI on the merge commit shows it.
With this one rename on the merged tree, the GenRM suites and the checkpoint unit tests pass, so app.py needs nothing for #3909, #4997 or #4679. Could you rebase onto main and apply:
| participant = ResourcesParticipant(server, "exported", verify_mode="replay") | |
| participant = ResourcesParticipant(server, "exported", verify_step_type="replay") |
There was a problem hiding this comment.
Rebased onto main and applied in 2c5a654. The GenRM suites pass on the rebased branch (277 passed).
| """ | ||
| now = time.monotonic() | ||
| terminal = [(key, cohort) for key, cohort in self._verify_cohorts.items() if cohort.terminal_at is not None] | ||
| needed = { |
There was a problem hiding this comment.
This keep check now runs participant.is_open for every member session of every finished cohort, on every verification that carries a group ID, while _cohort_registry_lock is held. Closed sessions stay in member_sessions, because the participant calls no hook when a session ends normally, so the scan covers them too.
At the defaults (4096 finished cohorts of 16 members), a prune measured a median of 7.6 ms with checkpointing on, against 3.1 ms with it off. That cost is paid about 16 times per cohort, once per member's verification, where the rebuild this replaced ran only when a cohort was evicted.
Could the check move to the eviction candidates only? In the time-based pass, test _needed_by_open_session only for cohorts whose age has passed cohort_result_ttl_s. In the count pass, walk oldest first and skip a needed cohort until the excess is dropped. The active-group set for watermarks then needs only the kept cohorts. The cost becomes proportional to what is evicted or kept rather than to everything retained, and the existing keep tests cover the behavior.
There was a problem hiding this comment.
Done in 93cdc01, as suggested: the age pass checks _needed_by_open_session only for cohorts past cohort_result_ttl_s, and the count pass walks oldest first, skipping a needed cohort until the excess is dropped. Every retained cohort keeps its group's watermark. At 4096 finished cohorts of 16 members, a prune with checkpointing on went from ~10.8 ms to ~2.1 ms, the same as with it off.
One small change in what is kept: a needed cohort among the newest now counts toward max_terminal_cohorts, so needed cohorts only go beyond the cap when they would otherwise be evicted. I updated the README to match. Disabling each of the three keep checks makes the existing tests fail.
GenRM computes every member's reward at once, but each reward reaches its member in its own reply. A checkpoint could land after one member recorded its reward and before the others did. After a crash those others replay verification, but the member that already recorded its reward never does, so the restored server waited for a cohort that could not complete until cohort_collection_timeout_s failed it. genrm_compare is now an exported resources server. Each session still open on a completed cohort exports its member's reward, and a restore rebuilds the completed cohort, with its group-attempt watermark, so a replayed verification gets its saved reward without running the judge again. A session whose cohort has no reward yet exports an empty state, and its members rebuild the cohort as before. While a checkpoint is open, a finished evaluation holds its rewards until resume. Otherwise a member could record a reward after this server exported, leaving the same mixed cut inside Gym. ResourcesParticipant gains session_episode(), which an exported server whose state is keyed by something other than the session uses to find what each session owns. Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
…play ResourcesParticipant.install() calls restore_session_states() before it binds each restored session to its next attempt, so GenRM could not record a restored member under its attempt. Its export looked members up only by attempt, so until a restored member replayed /verify its session exported an empty state. A second checkpoint in that window dropped the saved cohort again, and after another crash the members waited for the missing sibling until cohort_collection_timeout_s. A restore now also records each restored session's member by session ID, and the export falls back to it. Retiring a session and pruning its cohort drop the record. Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
… abandoned restored cohorts Review follow-ups on the cohort checkpoint export. Members are keyed by the resources session ID that verify now reads from the request, as workplace_assistant does. A restore installs the same session IDs, and a replay carries the same cookie, so one map serves a session before and after it replays. This removes the attempt-keyed map, the rollout-context lookup and ResourcesParticipant.session_episode(). Only members of cohorts with a group ID are recorded, so cohorts without one no longer leave entries behind. A judge failure reaches callers as a masked row they record like a reward. A cohort the judge failed now exports that failure, so a replay after a restore raises the same JudgeError and returns the same masked row, and the failure is held while a checkpoint is open, as rewards are. A failed restore, or a commit that leaves a restored episode out of scope, starts the rollout over from its input under the same group attempt. A restored member whose session is retired before it replays is now dropped, and a restored cohort left with no member is dropped with the attempt watermark its restore installed, so the group re-forms as it would in a fresh process. A member that replayed stays. Tests build on the real ResourcesParticipant and call verify with a request. Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
…ol plane Each cohort now keeps its member sessions, and the restored ones not yet replayed, so removing a cohort removes exactly its member records. Pruning no longer rebuilds the member map whenever it evicts a cohort. New tests cover the paths review found untested: member records ending with a pruned cohort, an outcome staying held when a checkpoint closes admission again while the outcome waits for the cohort lock (for rewards and for judge failures), restored sessions that disagree on a member, and a replay through the real HTTP checkpoint control plane after one and after two checkpoint-crash-restore cycles. The README describes how GenRM takes part in partial-rollout checkpoints. Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
…tate A checkpoint saves each member's response digest, and a replay matches its response against it. If _response_digest ever serializes responses differently, digests saved by one version would refuse every exact replay on the other. Saved members now name the scheme, json-ascii-sha256, and a restore refuses state saved under a scheme it does not know instead of installing digests it cannot compare. Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
…ll needs Pruning dropped finished cohorts by age and count whether or not a member still needed the outcome. A restore installs saved cohorts as ordinary tombstones, so a member replaying /verify could prune its own cohort first, then wait for a sibling that had already finished until the collection timeout. A live cohort could likewise be evicted before a checkpoint exported it for a member that had not recorded its outcome yet. A finished cohort with a member session the checkpoint participant still holds open is now kept past cohort_result_ttl_s and outside the max_terminal_cohorts count, together with its group's attempt watermark. It becomes an ordinary tombstone once those sessions close or are retired. Checkpoint tests now give the two members different rewards, so a member receiving its sibling's outcome fails them, and cover each member recovering its own outcome after two checkpoints. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
#4997 renamed the resources participant's verify_mode to verify_step_type, so building the participant in test_checkpoint.py raised TypeError on the merge with main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
The keep check asked the participant whether every member session of every finished cohort was open, on each grouped verification and under the registry lock. Check only cohorts past cohort_result_ttl_s and, in the count pass, the oldest cohorts until the excess is dropped. A needed cohort is still never dropped, and every retained cohort keeps its group's attempt watermark. At 4096 finished cohorts of 16 members, a prune with checkpointing on drops from about 10.8 ms to 2.1 ms, matching checkpointing off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
4ab8322 to
93cdc01
Compare
|
/ok to test 93cdc01 |
|
🌿 Preview your docs: https://nvidia-preview-pr-4792-93cdc0136bdb.docs.buildwithfern.com/nemo/gym |
|
🔄 Merge queue validation started! You can track the progress here: https://github.com/NVIDIA-NeMo/Gym/actions/runs/40423327775 |
|
🔄 Merge queue validation started! You can track the progress here: https://github.com/NVIDIA-NeMo/Gym/actions/runs/40423745533 |
Integrate #4792's GenRM checkpoint export and restore with this branch's compact cohorts and retention orders. - Name the orjson-based response digest orjson-sorted-sha256. Members restored from json-ascii-sha256 checkpoints keep that scheme: replays are compared under it, and later checkpoints export it. - Remove cohorts through _drop_cohort on expiry, count eviction and supersession, so member records end with their cohort. Restored cohorts join the result and attempt-record retention orders. - Keep a finished cohort an open session still needs, and its group's attempt record, by moving it to the back of its order and restarting its retention, rather than sorting every finished cohort on each prune. - Record the latest attempt's exact cohort key on the watermark. Fixed-baseline and rubric cohorts extend the group key, so supersession could not find them by group key alone. - Keep fixed-baseline and rubric scoring inputs on the cohort with the compact answers, and read reasoning and answer text for the reply without dumping the full response. - Adapt this branch's tests to verify(request, body), full comparison tuples and per-member result dictionaries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Yuhe Zhang <yuhez@nvidia.com>
What does this PR do?
Closes a race in which a GenRM cohort can stall for
cohort_collection_timeout_safter a crash and restore. Until nowgenrm_compare's own checkpoint comment documented this as a known limitation.The race. GenRM decides a cohort's outcome once, but each member receives it in its own reply. The outcome is a reward for every member, or, when the judge fails, a masked row that callers record like a reward. A checkpoint can land in between:
After the restore, members 1..3 replay
/verify. Member 0 never does, because it already finished, and the restarted server has no cohort state. Members 1..3 then wait for a cohort that can't complete untilcohort_collection_timeout_s(1800 s by default) fails it, and the group is retried from input.The fix.
genrm_comparebecomes anexportedserver;checkpoint_verifystaysreplay.verifyreads the resources session ID from the request, asworkplace_assistantdoes./verifywith the same response gets its saved reward through the existing completed-cohort path, or raises the sameJudgeErrorsojudge_failsafereturns the same masked row.response_digest_scheme(json-ascii-sha256). A restore refuses state saved under a scheme it doesn't know, so a future change to_response_digest(as in perf(genrm): reduce response copying and group cleanup overhead #3625) can't silently refuse every exact replay.aclose) removes exactly its records.cohort_result_ttl_sexpiry and themax_terminal_cohortscount, and its group keeps its attempt watermark. Otherwise a replay after a restore could prune its own cohort first and wait for a sibling that already finished, and a live cohort could be evicted before a checkpoint exported it. Once those sessions close or are retired, the cohort is pruned as before.No change outside
resources_servers/genrm_compare.Not covered: a member started over from its input while its restored cohort still holds continued members. That happens when Gym reports it as a restart while its siblings continue. It reruns with a new response and is refused as a different response for its slot. That case needs the controller to replace the whole group with a new
_ng_group_attempt.Validation
Run in the RL CI Gym image's
genrm_comparevenv, against this branch:resources_servers/genrm_compare/tests: 256 passed. The newtest_checkpoint.pyhas 27 tests on the realResourcesParticipant, covering:aclose, and cohorts without a group ID record none;verifydirectly now pass a session-less request (tests/support.py).tests/unit_tests/test_checkpoint_server_declarations.py,test_checkpoint_resources.pyandtest_checkpoint_resources_mcp.py: 102 passed.aclose;mainthrough the HTTP control plane: the replay stalls for the full collection timeout and then gets a 503, for rewards and for judge failures. On this branch it returns the recorded outcome at once.pre-commit run --fileson the changed files: all hooks pass.Rollouts
N/A. Without checkpointing, nothing changes: GenRM records no members and publishes as before. With checkpointing, verification results are unchanged; only their timing across a checkpoint and their recovery after a crash change.
Compatibility and benchmark impact
genrm_comparenow declarescheckpoint_mode = "exported"instead of"stateless", so its live sessions are part of a checkpoint and are restored with it.maintoday,installrefuses it ("a stateless resources server cannot restore session state"). With feat(checkpoint): resources, agent, and environment servers with several workers #3909's install changes, the records would instead be dropped and the members would fall back to rebuilding their cohorts by replaying. Restore a checkpoint with a Gym at least as new as the one that wrote it.verifynow takes the request (verify(self, request, body)), asworkplace_assistantdoes; callers through HTTP are unaffected.*, checkpoint_id; whichever of it and this PR lands second updates GenRM's override, or every commit raisesTypeError. perf(genrm): reduce response copying and group cleanup overhead #3625 changes the digest serialization; it should name a newresponse_digest_schemeand accept the old one on restore.Checklist
pre-commit run --all-files) (so CI lint/format checks pass). Run with--fileson the changed files; every hook passes.git commit -s) (so the DCO check passes).🤖 Generated with Claude Code